#
# fusion.sync.sync_manager
#
# Copyright 2007 Helsinki Institute for Information Technology
# and the authors.
#
# Authors: Ken Rimey <rimey@hiit.fi>
#

# Permission is hereby granted, free of charge, to any person
# obtaining a copy of this software and associated documentation files
# (the "Software"), to deal in the Software without restriction,
# including without limitation the rights to use, copy, modify, merge,
# publish, distribute, sublicense, and/or sell copies of the Software,
# and to permit persons to whom the Software is furnished to do so,
# subject to the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
# CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
# TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
# SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

import logging
from time import time
from os import urandom
from random import choice
from Queue import Queue, Empty
from binascii import b2a_hex as bytes_to_hex
from socket import gethostname, gethostbyname, inet_aton, error

from fusion.sync.sync_session import SyncSession
from fusion.discovery.advertise import advertise, advertisement_threads
from fusion.discovery.discover import discover, discovery_threads

class SyncManager(object):
    def __init__(self, db, stream_mgr, listening_address=None):
        self.sessions = set()
        self.db = db
        self.mgr = stream_mgr
        self.listening_address = listening_address
        self.my_id = bytes_to_hex(urandom(10))
        self.last_connection_update = 0
        self.last_advertisement_update = time()
        self.connection_attempts = {} # map (host, port) to time
        self.collections_to_sync = self.generate_collections_to_sync()
        self.discovery_results = Queue()
        if self.listening_address:
            self.server = self.listen(self.listening_address)

    def add(self, session):
        self.sessions.add(session)

    def discard(self, session):
        self.sessions.discard(session)

    def connect(self, address, id=None, factory=SyncSession, **options):
        stream = self.mgr.create_stream()
        session = factory(stream, self.db, self,
                          my_id = self.my_id,
                          peer_id = id,
                          listening_address = self.listening_address,
                          **options)
        session.connect(address)
        return session

    def listen(self, address):
        def handle_accept(stream_mgr, *args):
            stream = stream_mgr.create_stream(*args)
            SyncSession(stream, self.db, self,
                        my_id = self.my_id)
            server.accept(handle_accept)

        server = self.mgr.create_server()
        server.listen(address)
        server.accept(handle_accept)
        return server

    def update(self, connection_interval=5, advertisement_interval=30):
        self.update_sessions()
        self.update_connections(connection_interval)
        if self.listening_address:
            self.update_advertisements(advertisement_interval)

    def update_sessions(self):
        for session in self.sessions:
            session.update()

    def update_connections(self, interval):
        self.process_discovery_results()

        if not discovery_threads \
                and time() - self.last_connection_update > interval:
            self.last_connection_update = time()

            collection = self.collections_to_sync.next()
            if collection is not None:
                discover(collection, cb=self.discovery_results.put)

    def process_discovery_results(self):
        try:
            results = self.discovery_results.get_nowait()
            if results is not None:
                for advertisement in results:
                    peer_id = advertisement['id']
                    if not self.is_redundant_id(peer_id):
                        addresses = advertisement['addresses']
                        type = self.get_preferred_type(addresses)
                        addresses = filter_addresses(addresses, [type])
                        if addresses:
                            host, port = choice(addresses)
                            address = host, port
                            if not self.is_redundant_address(address):
                                last = self.connection_attempts.get(address, 0)
                                if time() - last > 60:
                                    self.connection_attempts[address] = time()
                                    self.connect(address, id=peer_id)
                                    break
        except Empty:
            pass
        except StandardError:
            logging.warning('Got bad data from discovery.')

    def is_redundant_id(self, id):
        return (id == self.my_id
                or any(id in session.peer_ids
                       for session in self.sessions))

    def is_redundant_address(self, (host, port)):
        return ((host, port) in self.get_my_addresses()
                or any((session.remote_host == host
                        and session.remote_port in (port, None))
                       for session in self.sessions))

    def get_preferred_type(self, addresses):
        hosts = set(host for host, port in addresses)

        his_private = filter_addresses(hosts, ['private'])
        if his_private:
            my_public = self.get_my_ip_addresses(['public'])
            if hosts.intersection(my_public):
                return 'private'

        return 'public'

    def generate_collections_to_sync(self, minimum=2):
        while True:
            for c in self.db.list_collections():
                if self.count_sessions(c) < minimum:
                    yield c
            yield None

    def count_sessions(self, collection):
        return len(self.list_sessions(collection))

    def list_sessions(self, collection):
        return [session for session in self.sessions
                if collection in session.collections_served]

    def update_advertisements(self, interval, ttl=600):
        if not advertisement_threads \
                and time() - self.last_advertisement_update > interval:
            self.last_advertisement_update = time()

            addresses = self.get_my_addresses()
            if addresses:
                data = {'id': self.my_id, 'addresses': addresses}
                collections = self.db.list_collections()
                logging.debug('Updating %d advertisements.' % len(collections))
                for collection in collections:
                    advertise(collection, data, ttl, 2 * interval)

    def get_my_addresses(self, types=['public', 'private']):
        port = self.get_my_port()
        if port is None:
            return []
        else:
            addresses = self.get_my_ip_addresses(types)
            return [(host, port) for host in addresses]

    def get_my_port(self):
        try:
            host, port = self.listening_address
            return port
        except ValueError:
            return None

    def get_my_ip_addresses(self, types):
        results = set()

        try:
            host = gethostbyname(gethostname())
            results.add(host)
        except error:
            pass

        for session in self.sessions:
            for address in (session.my_inward_address,
                            session.my_outward_address):
                if address:
                    host, port = address
                    results.add(host)

        return sorted(filter_addresses(results, types))

def filter_addresses(addresses, types):
    return [address for address in addresses
            if get_address_type(address) in types]

def get_address_type(address):
    if isinstance(address, str):
        host = address
    else:
        host, port = address

    try:
        s = inet_aton(host)
    except error:
        return 'illegal'

    a, b, c, d = map(ord, s)
    if a == 0:
        return 'zero'
    elif a == 10:
        return 'private'
    elif a == 127:
        return 'localhost'
    elif a == 169 and b == 254:
        return 'zeroconf'
    elif a == 172 and 16 <= b <= 31:
        return 'private'
    elif a == 192 and b == 0 and c == 2:
        return 'documentation-and-examples'
    elif a == 192 and b == 88 and c == 99:
        return 'ipv6-to-ipv4-relay-anycast'
    elif a == 192 and b == 168:
        return 'private'
    elif a == 198 and 18 <= b <= 19:
        return 'network-device-benchmark'
    elif 224 <= a <= 239:
        return 'multicast'
    elif 240 <= a <= 255:
        return 'reserved'
    else:
        return 'public'
